大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 23 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。本章節講述的是如何透過 framebuffer 使 WebGL 預先計算資料到 texture,並透過這些預計算的資料製作鏡面、陰影效果,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
取得了 framebuffer 這個工具,把球體畫在在一幅畫中,已經完成鏡面效果所需要的基石,本篇來把鏡面效果實做出來
上篇建立 framebuffer 時直接使用 WebGL API 來建立 framebuffer,其實 TWGL 已經有時實做好一定程度的包裝,我們可以呼叫 twgl.createFramebufferInfo()
,它會建立好 framebuffer, textures 並且為他們建立關聯:
framebuffers.mirror = twgl.createFramebufferInfo(
gl,
null, // attachments
2048, // width
2048, // height
);
attachments
讓開發者可以指定要寫入的 texture 的設定,例如說 gl.COLOR_ATTACHMENT0
所對應的顏色部份要寫入的 texture 的設定,筆者傳 null 讓 twgl 使用預設值建立一個顏色 texture 以及一個深度資訊 texture,因為接下來要實做的功能為鏡面,把此 framebuffer 命名為 framebuffers.mirror
那麼要怎麼取得自動建立的 texture 呢?嘗試用 Console 查看建立的物件 framebufferInfo
看起來像是這樣:
看起來就放在 attachments
下呢,那麼把 texture 指定到 textures
物件中以便之後取用:
textures.mirror = framebuffers.mirror.attachments[0];
值得注意的是,framebufferInfo
同時包含了長寬資訊,如果使用 twgl.bindFramebufferInfo()
來做 framebuffer 的切換,它同時會幫我們呼叫 gl.viewport()
調整渲染區域,因此在繪製階段也使用 twgl 所提供的工具:
function render(app) {
// ...
- gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffers.fb.framebuffer);
- gl.viewport(0, 0, framebuffers.fb.width, framebuffers.fb.height);
- gl.clear(gl.COLOR_BUFFER_BIT);
+ twgl.bindFramebufferInfo(gl, framebuffers.mirror);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
renderBall(app, viewMatrix);
// ...
}
可以發現在 gl.clear()
時除了清除 gl.COLOR_BUFFER_BIT
,也要清除gl.DEPTH_BUFFER_BIT
,這是因為 twgl.createFramebufferInfo()
所建立的組合預設包含了一張深度資訊,這個資訊也得清除以避免第二次渲染到 framebuffer 時產生問題
目前在 framebuffer 中繪製的球體就是正常狀態下看到的球體,那麼要怎麼繪製『鏡像』的樣子呢?想像一個觀察著看著鏡面中的一顆球:
橘色箭頭為實際光的路線,把光線打直可以獲得一個鏡面中的觀察者看著真實世界(灰色箭頭與眼睛),因此繪製鏡像中的世界時,把相機移動到鏡面中拍一次,我們就獲得了鏡面世界的成像,準備好在繪製場景時使用
筆者為此章節實做的相機控制方式使用了不同於 matrix4.lookAt()
的 cameraMatrix
產生方式:
const cameraMatrix = matrix4.multiply(
matrix4.translate(...state.cameraViewing),
matrix4.yRotate(state.cameraRotationXY[1]),
matrix4.xRotate(state.cameraRotationXY[0]),
matrix4.translate(0, 0, state.cameraDistance),
);
用白話文來說,目前的相機一開始在 [0, 0, 0]
看著 -z 方向,先往 +z 方向移動 state.cameraDistance
、轉動 x 軸 state.cameraRotationXY[0]
、轉動 y 軸 state.cameraRotationXY[1]
,這時相機會在半徑為 state.cameraDistance
的球體表面上看著原點,最後 state.cameraViewing
的平移是指移動相機所看的目標,如果使用 y = 0
形成的平面作為鏡面,只要讓轉動 x 軸時反向,就變成對應在鏡面中的相機,並且進而算出鏡面使用的 viewMatrix
:
const mirrorCameraMatrix = matrix4.multiply(
matrix4.translate(...state.cameraViewing),
matrix4.yRotate(state.cameraRotationXY[1]),
matrix4.xRotate(-state.cameraRotationXY[0]),
matrix4.translate(0, 0, state.cameraDistance),
);
const mirrorViewMatrix = matrix4.multiply(
matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000),
matrix4.inverse(mirrorCameraMatrix),
);
接著讓地板為 y = 0
形成的平面,與球體一同向 +y 方向移動一單位:
function renderBall(app, viewMatrix) {
// ...
const worldMatrix = matrix4.multiply(
- matrix4.translate(0, 0, 0),
matrix4.scale(1, 1, 1),
);
// ...
}
function renderGround(app, viewMatrix) {
// ...
- const worldMatrix = matrix4.multiply(
- matrix4.translate(0, -1, 0),
- matrix4.scale(10, 1, 10),
- );
+ const worldMatrix = matrix4.scale(10, 1, 10);
// ...
}
最後在繪製鏡像中的世界時使用 mirrorViewMatrix
:
function render(app) {
// ...
twgl.bindFramebufferInfo(gl, framebuffers.mirror);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
- renderBall(app, viewMatrix);
+ renderBall(app, mirrorViewMatrix);
// ...
}
儘管繪製出鏡面世界中的樣子,拍了一張鏡面世界的照片,但是要怎麼在正式『畫』的時候找到鏡面世界的照片對應的位置呢?請看下面這張圖:
物件上的一個點 A,是經過物件自身 worldMatrix
transform 的位置,再經過 mirrorViewMatrix
transform 到鏡面世界的照片上(點 B);正式『畫』鏡面物件時,我們知道的是 C 點的位置(worldPosition
),而這個點座落在 A 與 B 點之間,因此拿著 C 點做 mirrorViewMatrix
transform 便可以獲得對應的 B 點,這時 B 點是 clip space 中的位置,只要再將此位置向量加一除以二就能得到 texture 上的位置囉
也就是說,在正式『畫』的時候也會需要 mirrorViewMatrix
,uniform 命名為 u_mirrorMatrix
,並且在 vertex shader 中計算出 B 點,透過 varying v_mirrorTexcoord
傳送給 fragment shader:
// ...
+uniform mat4 u_mirrorMatrix;
+varying vec4 v_mirrorTexcoord;
void main() {
// ...
- vec3 worldPosition = (u_worldMatrix * a_position).xyz;
- v_surfaceToViewer = u_worldViewerPosition - worldPosition;
+ vec4 worldPosition = u_worldMatrix * a_position;
+ v_surfaceToViewer = u_worldViewerPosition - worldPosition.xyz;
+
+ v_mirrorTexcoord = u_mirrorMatrix * worldPosition;
}
到 fragment shader,筆者打算讓鏡面世界的照片放在 u_diffuseMap
,不過鏡面物體取用 texture 的方式將會與其他物件不同,因此加入一個 uniform u_useMirrorTexcoord
來控制是否要使用 v_mirrorTexcoord
// ...
+uniform bool u_useMirrorTexcoord;
+varying vec4 v_mirrorTexcoord;
void main() {
+ vec2 texcoord = u_useMirrorTexcoord ?
+ (v_mirrorTexcoord.xy / v_mirrorTexcoord.w) * 0.5 + 0.5 :
+ v_texcoord;
- vec3 diffuse = u_diffuse + texture2D(u_diffuseMap, v_texcoord).rgb;
+ vec3 diffuse = u_diffuse + texture2D(u_diffuseMap, texcoord).rgb;
vec3 ambient = u_ambient * diffuse;
- vec3 normal = texture2D(u_normalMap, v_texcoord).xyz * 2.0 - 1.0;
+ vec3 normal = texture2D(u_normalMap, texcoord).xyz * 2.0 - 1.0;
// ...
}
可以注意到 u_useMirrorTexcoord
為 true 時,有個 (v_mirrorTexcoord.xy / v_mirrorTexcoord.w)
,為什麼要除以 .w
呢?還記得 Day 12 時,頂點位置在進入 clip space 之前,會把 gl_Position.x
, gl_Position.y
, gl_Position.z
都除以 gl_Position.w
,而 varying v_mirrorTexcoord
當然就沒有這樣的行為了,我們得自己實做,然後 * 0.5 + 0.5
就是把 clip space 位置(-1 ~ +1)轉換成 texture 上的 texcoord (0 ~ +1)
完成 shader 的修改,剩下的就是把需要餵進去的 uniform 餵進去,並且在正式『
『畫』的時候也畫出球體:
function render(app) {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.canvas.width = gl.canvas.clientWidth;
gl.canvas.height = gl.canvas.clientHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
+ renderBall(app, viewMatrix);
- renderGround(app, viewMatrix);
+ renderGround(app, viewMatrix, mirrorViewMatrix);
}
-function renderGround(app, viewMatrix) {
+function renderGround(app, viewMatrix, mirrorViewMatrix) {
// ...
twgl.setUniforms(programInfo, {
// ...
- u_diffuseMap: textures.fb,
+ u_diffuseMap: textures.mirror,
// ...
+ u_useMirrorTexcoord: true,
+ u_mirrorMatrix: mirrorViewMatrix,
});
twgl.drawBufferInfo(gl, objects.ground.bufferInfo);
+
+ twgl.setUniforms(programInfo, {
+ u_useMirrorTexcoord: false,
+ });
}
在最後還有特地把 u_useMirrorTexcoord
關閉,因為只有地板物件會需要這個特殊的模式,而 uniform 是跟著 program 的,畫完此物件立刻關閉可以避免影響到其他物件的渲染
鏡面效果就完成了:
本篇的完整程式碼可以在這邊找到: